As the name implies, a table-valued parameter (TVP) lets you pass an entire set of rows as a single parameter
to T-SQL stored procedures and user-defined functions (UDFs). This is
extremely useful in and of itself, but arguably the most compelling
facet of TVPs is their ability to marshal an entire set of rows across
the network, from your .NET client to your SQL Server database, with a
single stored procedure call (one roundtrip) and a single table-valued
parameter.
Prior
to SQL Server 2008, developers were forced to resort to clever hacks in
an effort to reduce multiple roundtrips into one when inserting
multiple rows—using techniques such as XML, delimited or encoded text,
or even (gasp) accepting hundreds (up to 2100!) of parameters. But
special logic then needs to be implemented for packaging and unpackaging
the parameter values on both sides of the wire. Worse, the code to
implement that logic is often gnarly and unmaintainable. None of those
techniques even come close to the elegance and simplicity of using TVPs,
which offer a native solution to this problem.
More Than Just Another Temporary Table Solution
A TVP is based on a user-defined table type,
which you create to describe the schema for a set of rows that can be
passed to stored procedures and UDFs. It’s helpful to begin
understanding TVPs by first comparing them to similar “set” constructs,
such as table variables, temp tables, and Common
Table Expressions (CTEs). All of these provide a source of tabular data
that you can query and join against, so you can treat a TVP, table
variable, temporary table, or CTE just like you would an ordinary table
or view in virtually any scenario.
CTEs and table variables store
their row data in memory—assuming reasonably sized sets that don’t
overflow the RAM cache allocated for them, in which case, they do push
their data into tempdb. In contrast, a TVP’s data is always stored in tempdb. When you first populate a TVP, SQL Server creates a table in tempdb
to back that TVP as it gets passed from one stored procedure (or UDF)
to another. Once the stack unwinds and the TVP falls out of scope in
your T-SQL code, SQL Server cleans up tempdb automatically. You never interact directly with tempdb, because TVPs provide a total abstraction over it.
The
true power of TVPs lies in the ability to pass an entire table (a set
of rows) as a single parameter from client to server, and between your
T-SQL stored procedures and user-defined functions. Table variables and
temporary tables, on the other hand, cannot be passed as parameters.
CTEs are limited in scope to the statement following their creation and
are therefore inherently incapable of being passed as parameters.
Reusability
is another side benefit of TVPs. The schema of a TVP is centrally
maintained, which is not the case with table variables, temporary
tables, and CTEs. You define the schema once by creating a new
user-defined type (UDT) of type table, which you do by applying the AS TABLE clause to the CREATE TYPE statement, as shown in Example 1.
Example 1. Defining the schema for a user-defined table type.
CREATE TYPE CustomerUdt AS TABLE
(Id int,
CustomerName nvarchar(50),
PostalCode nvarchar(50))
This statement creates a new user-defined table type named CustomerUdt with three columns. TVP variables of type CustomerUdt can then be declared and populated with rows of data that fit this schema, and SQL Server will store and manage those rows in tempdb
behind the scenes. These variables can be passed freely between stored
procedures—unlike regular table variables, which are stored in RAM
behind the scenes and cannot be passed as parameters. When TVP variables
declared as CustomerUdt fall out of scope and are no longer referenced, the underlying data in tempdb supporting the TVP is deleted automatically by SQL Server.
You can see that, in fact, a TVP is essentially a user-defined table type.
Populated instances of this type can be passed on as parameters to
stored procedures and user-defined functions—something you still can’t
do with a regular table variable. Once the table type is defined, you
can create stored procedures with parameters of that type to pass an
entire set of rows using TVPs.
TVP types are displayed in Visual Studio’s SQL Server Object Explorer in the User-Defined Table Types node beneath Programmability | Types, as shown in Figure 1 (as it does in SQL Server Management Studio’s Object Explorer).
There
are many practical applications for passing entire sets of data around
as parameters, and we’ll explore a number of them in the rest of this
section.
A
typical scenario in which TVPs can be applied is an order entry system.
When a customer places an order, a new order row and any number of new
order detail rows must be created in the database. Traditionally, this
might be accomplished by creating two stored procedures—one for
inserting an order row and one for inserting an order detail row. The
application would invoke a stored procedure call for each individual
row, so for an order with 20 details, there would be a total of 21
stored procedure calls (1 for the order and 20 for the details). There
could of course be even larger orders with many more than 20 details. As
a result, numerous roundtrips are made between the application and the
database, each one carrying only a single row of data.
Enter
TVPs. Now you can create a single stored procedure with just two TVPs,
one for the order row and one for the order details rows. The client can
now issue a single call to this stored procedure, passing to it the
entire order with all its details, as shown in Example 2.
Note
The code in Example 2 assumes that the Order and OrderDetail tables already exist, and that the OrderUdt and OrderDetailUdt table types have already been created with a column schema that matches the tables.
Example 2. Creating a stored procedure that accepts TVPs.
CREATE PROCEDURE uspInsertNewOrder
(@OrderTvp AS OrderUdt READONLY,
@OrderDetailsTvp AS OrderDetailUdt READONLY)
AS
INSERT INTO [Order]
SELECT * FROM @OrderTvp
INSERT INTO [OrderDetail]
SELECT * FROM @OrderDetailsTvp
As you can see, this code inserts into the Order and OrderDetail tables directly from the rows passed in through the two TVPs. You are essentially performing a bulk insert with a single call, rather than individual inserts across multiple calls wrapped in a transaction.
We’ll now take look at the bulk
insert possibilities for TVPs and how to create, declare, populate, and
pass TVPs in T-SQL. Then we’ll demonstrate how to populate TVPs and
pass them across the network from .NET client application code to stored
procedures using ADO.NET.
Using TVPs for Bulk Inserts and Updates
Here’s an example of a stored procedure that you can create in the AdventureWorks2012 database that accepts a TVP and inserts all of the rows that get passed in through it into the Product.Location table. By creating a user-defined table type named LocationUdt
that describes the schema for each row passed to the stored procedure,
any code can call the stored procedure and pass to it a set of rows for
insertion into Product.Location using a single parameter typed as LocationUdt.
First, create the user-defined table data type LocationUdt, as shown in Example 3.
Example 3. Creating the LocationUdt table type to be used for bulk operations with TVPs.
CREATE TYPE LocationUdt AS TABLE(
LocationName varchar(50),
CostRate int)
Now a TVP variable of this type can be declared to hold a set of rows with the two columns LocationName and CostRate.
These rows can be fed to a stored procedure by passing the TVP variable
into it. The stored procedure can then select from the TVP just like a
regular table or view and thus use it as the source for an INSERT INTO…SELECT statement that appends each row to the Product.Location table.
Rows added to Product.Location
require more than just the two fields for the location name and cost
rate. The table also needs values for the availability and modified date
fields, which you’ll let the stored procedure handle. What you’re doing
here is defining a schema that can provide a subset of the required Product.Location fields (Name and CostRate), for passing multiple rows of data to a stored procedure that provides values for the remaining required fields (Availability and ModifiedDate). In the example, the stored procedure sets Availability to 0 and ModifiedDate to the GETDATE function on each row of data inserted from the TVP (passed in as the only parameter) that provides the values for Name and CostRate, as shown in Example 4.
Example 4. Creating a stored procedure to perform a bulk insert using a TVP declared as the LocationUdt table type.
CREATE PROCEDURE uspInsertProductionLocation
(@TVP LocationUdt READONLY)
AS
INSERT INTO [Production].[Location]
([Name], [CostRate], [Availability], [ModifiedDate])
SELECT *, 0, GETDATE() FROM @TVP
You now have a
stored procedure that can accept a TVP containing a set of rows with
location names and cost rates to be inserted into the Production.Location
table, and that sets the availability quantity and modified date on
each inserted row—all achieved with a single parameter and a single INSERT INTO…SELECT statement! The procedure doesn’t know or care how the caller populates the TVP before it is used as the source for the INSERT INTO…SELECT statement. For example, the caller could manually add one row at a time, as follows:
DECLARE @LocationTvp AS LocationUdt
INSERT INTO @LocationTvp VALUES('UK', 122.4)
INSERT INTO @LocationTvp VALUES('Paris', 359.73)
EXEC uspInsertProductionLocation @LocationTvp
Or the caller could bulk insert into the TVP from another source table using INSERT INTO…SELECT, as in the next example. You will fill the TVP from the existing Person.StateProvince table using the table’s Name column for LocationName and the value 0 for CostRate. Passing this TVP to the stored procedure will result in a new set of rows added to Production.Location with their Name fields set according to the names in the Person.StateProvince table, their CostRate and Availability values set to 0, and their ModifiedDate values set by GETDATE, as shown here:
DECLARE @LocationTVP AS LocationUdt
INSERT INTO @LocationTVP
SELECT [Name], 0.00 FROM [Person].[StateProvince]
EXEC uspInsertProductionLocation @LocationTVP
The TVP could also be populated on the client using ADO.NET, as you’ll learn in the next section.
Bulk updates (and deletes) using TVPs are possible as well. You can create an UPDATE
statement by joining a TVP (which you must alias) to the table you want
to update. The rows updated in the table are determined by the matches
joined to by the TVP and can be set to new values that are also
contained in the TVP. For example, you can pass a TVP populated with
category IDs and names for updating a Category table in the database, as shown in Example 5. By joining the TVP to the Category table on the category ID, the uspUpdateCategories stored procedure can update all the matching rows in the Category with the new category names passed in the TVP.
Example 5. Bulk updates using TVPs.
CREATE TABLE Category
(Id int PRIMARY KEY,
Name nvarchar(max),
CreatedAt datetime2(0) DEFAULT SYSDATETIME())
-- Initialize with a few categories
INSERT INTO Category(Id, Name) VALUES(1, 'Housewares')
INSERT INTO Category(Id, Name) VALUES(2, 'Maternity')
INSERT INTO Category(Id, Name) VALUES(3, 'Mens Apparel')
INSERT INTO Category(Id, Name) VALUES(4, 'Womens Apparel')
INSERT INTO Category(Id, Name) VALUES(5, 'Bath')
INSERT INTO Category(Id, Name) VALUES(6, 'Automotive')
-- View the list of categories
SELECT * FROM Category
-- Will be used by uspUpdateCategories to pass in a set of category updates
CREATE TYPE EditedCategoriesUdt AS TABLE
(Id int PRIMARY KEY,
Name nvarchar(max))
GO
-- Receive multiple rows for the change set and update the Category table
CREATE PROCEDURE uspUpdateCategories(@EditedCategoriesTVP AS EditedCategoriesUdt
READONLY)
AS
BEGIN
-- Update names in the Category table by joining on the TVP by ID
UPDATE Category
SET Category.Name = ec.Name
FROM Category INNER JOIN @EditedCategoriesTVP AS ec ON Category.Id = ec.Id
END
-- Load up a few changes into a new TVP instance (to categories 1 and 5)
DECLARE @Edits AS EditedCategoriesUdt
INSERT INTO @Edits VALUES(1, 'Gifts & Housewares')
INSERT INTO @Edits VALUES(5, 'Bath & Kitchen')
-- Call the stored procedure
EXECUTE uspUpdateCategories @Edits
-- View the updated names for categories 1 and 5 in the Category table
SELECT * FROM Category